
打開一張表單,框架要讀的定義不只一份:FormSchema、TableSchema、FormLayout、ProgramSettings、LanguageResource,一份都不能少。這些定義上線後還是會改,但執行期間很少異動,這類資料最適合快取。剩下的都是快取自己的問題:哪些資料適合、切多細、放多久、什麼時候該釋放,還有源頭被改掉之後怎麼換上新的一份。
本篇說明四件事,Day 1 提過的那個通知機制是第三件:
第一件決定這個快取划不划算,後三件決定它會不會安靜地給出錯的答案。
痛點:上千份定義的系統,兩個極端都走不通。
框架沒有在兩個極端之間選,而是把問題換小:這份東西每次被讀的時候,讀的是「整份」還是「其中一個」?
| 基底 | 適用 | 用什麼當鍵 |
|---|---|---|
ObjectCache<T> |
整份就是一個物件,全系統只有一份 | 型別名 |
KeyObjectCache<T> |
同一種東西很多份,每次只讀其中一份 | 型別名加上那一份的鍵 |
十三種定義照這條線一分為二:
SystemSettings、DatabaseSettings、DbCategorySettings、ProgramSettings、MenuSettings、PluginSettings、PermissionModels、CurrencySettings、UnitSettings
FormSchema、TableSchema、FormLayout、LanguageResource
Day 4 那句「快取的單位就是讀取的單位」,這一節是它的實作面。一個帶鍵的快取只要交代一件事:沒命中的時候怎麼用這個鍵把東西讀出來。FormSchemaCache 就是這個形狀:
public class FormSchemaCache : KeyObjectCache<FormSchema>
{
protected override FormSchema? CreateInstance(string key)
{
string progId = key;
return _storage.GetFormSchema(progId);
}
}
沒有預熱、沒有載入清單。第一個人打開哪張表單,那張表單的定義才進記憶體。留多久則由使用頻率決定:快取用的是滑動過期,被讀到就重新計時,所以越常被讀到的那幾份留得越久,冷掉的自己退場。
員工與部門這種被到處指著的表單落在最熱的那一頭:有人打開它要讀一次 FormSchema,別張表單的關連指過來、組查詢的時候又要讀一次,所以執行期間它們幾乎不會冷掉。
粒度往粗切一格會怎樣:假設 FormSchema 全部裝進同一個物件,讀取這一側沒有差別。代價出現在另一側,任何人改了任何一張表單,所有表單的定義一起失效、一起重讀。
快取的粒度不只是「一次讀多少」,它同時決定了「一次要丟掉多少」。
框架的快取裡還有另一類東西,來源不是定義檔,是資料庫裡的實際資料:公司的基本資料、公司層的角色授權、部門的組織樹。形狀跟定義快取一模一樣,沒命中就往來源讀一次。
它們留在記憶體裡的理由跟定義一樣,還多一條:會用到它們的那一段路徑不該再多開一次查詢(判斷一個呼叫有沒有權限、決定這次要連哪一個資料庫,都發生在請求的最前面)。
應用自己的資料也一樣。ERP 裡不常異動、每次請求又都要查的東西不少:稅率、倉庫、簽核層級,還有各式各樣的參數檔,每一次都回資料庫問一遍並不划算。上面那兩個基底不是框架自己專用的,應用要的那幾份走的是同一條路。
還有一種痛點跟命中無關:有人拿一個不存在的 ProgId 一直打進來,每次都沒命中,於是每次都去讀一遍根本不存在的檔案。
帶鍵那個基底把「查過沒有」也記下來,之後同一個鍵直接回沒有。這個標記用絕對過期而不是滑動過期,所以反覆戳同一個鍵不會延長它的壽命。
反面也有一個例子:有一種快取刻意把這個行為關掉。它的鍵是任何人都能自己送進來的,記「查過沒有」會讓那些鍵在記憶體裡各留一個標記,而它省下的只是一次走索引、回零列的查詢。預設值該不該套用,看的是那個快取的鍵是誰在決定。
這是一個兩派各有理的題目,本系列不選邊:快取放在每個行程自己的記憶體裡,還是放在一台共用的快取伺服器上。
框架這一層的需求很具體:定義是唯讀的、每次請求要讀好幾份、讀到的是同一個共用實例。這種存取形狀放到行程外面,等於在每次請求裡加上幾趟網路往返,去換一份本來就不太會變的東西。
框架自己也沒把這件事釘死:底層儲存是一個只有五個方法的介面(Contains / Set / Get / Remove / GetCount),換掉它不必動任何一個快取類別。不過隨套件出貨的實作只有一個,包的是 .NET 的 MemoryCache,換別的是留著的空間,不是已經有的功能。
網站有好幾個節點分攤流量,數量還會跟著流量自動增減;另外還有不接請求的背景服務,排程與批次各跑各的。一個節點就是一個行程,它們讀的是同一批定義。
改定義只會發生在其中一個節點上。那一個好辦,順手把自己的快取清掉就結束;其餘的節點手上那一份舊定義不會有人去動,它們會繼續照舊的形狀組 SQL、渲染畫面、跑驗證規則,一聲不響。
每個節點各有一份,所以每個節點都得自己知道什麼時候該丟掉它。
痛點在開發時就會遇到:改了一份定義檔、重新整理畫面,看到的還是舊的。一個行程就會這樣,多節點只是把同一件事放大。
Day 4 寫過「每一份快取各自盯著自己的來源」,這一節是它的展開。而「來源」不是同一種東西:可能是一個檔案,也可能是資料庫裡的一張或多張表。前者看得到修改時間,後者沒有。
框架的分工是讓儲存體自己回報它能提供什麼信號,快取那一側再翻成自己的過期政策。這一步就在上面那個 FormSchemaCache 的另一個方法裡:
var changeSource = _storage.GetChangeSource(DefineType.FormSchema, progId);
policy.ChangeMonitorFilePaths = changeSource.FilePaths;
policy.ChangeNotifyKey = changeSource.NotifyKey;
定義放在檔案裡,儲存體回報的就是一組檔案路徑;放在資料庫裡,回報的就是一個通知鍵。兩個不會同時有值。也有兩種都給不出來的時候,那一筆就只能等時間到了自己過期。快取這一邊不必知道自己拿到的是哪一種,儲存體給什麼就盯什麼。
信號由儲存體回報,不是由快取去猜。
換成快取自己猜,每一個快取類別都得寫一次「我的東西放在哪裡、所以我該盯什麼」;哪天多一種儲存體,每一個都要回頭改。
上一節那些行程擺在哪裡有兩種形狀,形狀決定拿得到哪一種信號。
檔案那一種是開發期的形狀:定義檔要進版控、要跟著分支走。而個人環境本來就是同一台主機共用一個定義目錄,誰改了檔案,其他行程下一次讀取就看得到。
實際運行環境通常是多台主機。各台看的是自己的檔案系統,改一台的檔案,另外幾台不會知道。這不是效率差一點,是完全收不到信號。所以定義要搬進資料庫,失效改走通知表,代價是它從此不在版控裡。
這條線只切定義那一半。第一節那些來源是資料庫的快取沒有檔案可以盯,不論定義放在哪裡都走通知表。所以後面兩節談的都是通知表那一條,而它在任何部署形狀下都在。
上面那個過期政策,拆開來是四個條件,決定一筆條目能活多久。它們分成兩類,每一類二選一:
| 類 | 過期條件 | 怎麼判定 |
|---|---|---|
| 時間 | 滑動過期 | 一段時間沒被讀到才丟掉,被讀到就重新計時 |
| 時間 | 絕對過期 | 到了那個時刻就丟掉,讀取不會延長 |
| 變更信號 | 檔案修改時間 | 記下建立當下的修改時間,不一樣就過期 |
| 變更信號 | 通知鍵版本 | 記下建立當下的版本號,不一樣就過期 |
定義快取走滑動,「查過沒有」那個標記走絕對;變更信號那兩個則看儲存體回報哪一種。
變更信號的比對要等快取被用到才發生,沒有任何東西會主動來推。所以失效不重載,只是讓下一次讀取拿到新的值;沒有人在讀的那些鍵,來源改了一百次也不會替它們白跑一百趟。
上一節說資料庫那一種信號是一個通知鍵。鍵記在哪裡、誰去累加它的版本,這一節講完。
| 欄位 | 內容 |
|---|---|
cache_key |
那個通知鍵,同時是主鍵 |
cache_version |
這個鍵被改過幾次,逐次累加 |
sys_update_time |
資料庫伺服器的時間,供增量抓取用 |
機制本身兩步:來源資料被改的時候,那一段業務邏輯順手往通知表寫一列、把版本累加;每個節點各有一個輪詢器盯著這張表,看到版本變大,就讓掛在那個鍵上的快取失效。
中間沒有路由表。通知鍵的形狀是一個慣例,群組加上實體,例如 FormSchema:Order。群組就是被快取的那個型別的名字,鍵本身就把「這一則該通知誰」講完了,所以新增一個快取不必登記到任何地方。
通知表只有一張,在 Day 11 那張分類表的 common 上,輪詢器盯的也是它。來源資料在公司那一邊,累加版本的那一列仍然寫回這一張。
改完資料要記得累加一次版本,這一行是寫業務邏輯的人自己寫的。看到這種形狀,很容易想到讓資料庫自己來:在那張表上掛一個觸發器,連寫都不必寫,而且不可能忘記。
這條路沒有走,理由是相依關係的單位不對。快取相依的是一個語意,不是一整張表:
觸發器看得到的只有「這張表的這一列變了」,表達不出「這次的改動對某一份快取有意義」這個粒度,而能判斷這件事的只有寫那段業務邏輯的人。
代價是那一行會被忘記,症狀是其他節點一直拿舊值,而那一份越常被讀到,越不會靠滑動過期自己退場。這個代價是刻意換的:忘記通知,錯的只有那一份快取;通知得太廣,每一次無關的異動都讓所有節點重讀一遍。
通知表上已經有更新時間了,比一下不就知道有沒有變嗎?三個地方會壞:
框架的作法是把兩件事分開:
時間只負責便宜地抓增量,版本負責判定。
更新時間那一欄有索引,所以輪詢器每一輪只抓「時間在我上次看到的位置之後」的那幾列,而且刻意往回多看一段。抓回來之後判定完全不看時間,只看版本,比上次記下的那個大才動作。
少了前者會漏,少了後者會重複,兩個都不會報錯,只會讓行為時對時錯。
第一輪只記下起點,不失效任何東西:剛啟動的行程手上的快取是空的,通知表裡的歷史紀錄對它沒有意義。
時鐘那一件只需要知道一個事實:寫入的時間、每個節點記的位置、抓取用的門檻,全部來自資料庫伺服器,沒有一個來自節點自己。
輪詢迴圈還把每一輪包起來,資料庫瞬斷只記一筆警告然後繼續。這個迴圈一旦因為一次例外而結束,失效就不是延遲,是從此停止,而且整個行程還活得好好的。
這一整套收成一句話:伺服器手上的記憶體只花在真的有人在用的定義上。
這四條加起來,記憶體怎麼分配不必有人事先規劃。代價在另一邊:來源在資料庫的那些,異動時要主動更新通知表,否則其他節點拿不到最新的值。
明天開始換一層:業務邏輯動手之前要先知道現在是誰在呼叫、他在哪一家公司,而那兩個答案是誰準備的、又活多久。
本系列同步發表於 HackMD,完整目錄